Constraints¶
Industrial boiler with startup costs, minimum uptime, and load constraints.
This notebook introduces:
- StatusParameters: Model on/off decisions with constraints
- Startup costs: Penalties for turning equipment on
- Minimum uptime/downtime: Prevent rapid cycling
- Minimum load: Equipment can't run below a certain output
Setup¶
In [1]:
Copied!
import numpy as np
import pandas as pd
import plotly.express as px
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import plotly.express as px import xarray as xr import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
System Description¶
The factory has:
- Industrial boiler: 500 kW capacity, startup cost of 50€, minimum 4h uptime
- Small backup boiler: 100 kW, no startup constraints (always available)
- Steam demand: Varies with production schedule (high during shifts, low overnight)
The main boiler is more efficient but has operational constraints. The backup is less efficient but flexible.
Define Time Horizon and Demand¶
In [2]:
Copied!
# 3 days, hourly resolution
timesteps = pd.date_range('2024-03-11', periods=72, freq='h')
hours = np.arange(72)
hour_of_day = hours % 24
# Factory operates in shifts:
# - Day shift (6am-2pm): 400 kW
# - Evening shift (2pm-10pm): 350 kW
# - Night (10pm-6am): 80 kW (maintenance heating only)
steam_demand = np.select(
[
(hour_of_day >= 6) & (hour_of_day < 14), # Day shift
(hour_of_day >= 14) & (hour_of_day < 22), # Evening shift
],
[400, 350],
default=80, # Night
)
# Add some variation
np.random.seed(123)
steam_demand = steam_demand + np.random.normal(0, 20, len(steam_demand))
steam_demand = np.clip(steam_demand, 50, 450).astype(float)
print(f'Peak demand: {steam_demand.max():.0f} kW')
print(f'Min demand: {steam_demand.min():.0f} kW')
# 3 days, hourly resolution timesteps = pd.date_range('2024-03-11', periods=72, freq='h') hours = np.arange(72) hour_of_day = hours % 24 # Factory operates in shifts: # - Day shift (6am-2pm): 400 kW # - Evening shift (2pm-10pm): 350 kW # - Night (10pm-6am): 80 kW (maintenance heating only) steam_demand = np.select( [ (hour_of_day >= 6) & (hour_of_day < 14), # Day shift (hour_of_day >= 14) & (hour_of_day < 22), # Evening shift ], [400, 350], default=80, # Night ) # Add some variation np.random.seed(123) steam_demand = steam_demand + np.random.normal(0, 20, len(steam_demand)) steam_demand = np.clip(steam_demand, 50, 450).astype(float) print(f'Peak demand: {steam_demand.max():.0f} kW') print(f'Min demand: {steam_demand.min():.0f} kW')
Peak demand: 435 kW Min demand: 50 kW
In [3]:
Copied!
px.line(x=timesteps, y=steam_demand, title='Factory Steam Demand', labels={'x': 'Time', 'y': 'kW'})
px.line(x=timesteps, y=steam_demand, title='Factory Steam Demand', labels={'x': 'Time', 'y': 'kW'})
Build System with Operational Constraints¶
In [4]:
Copied!
flow_system = fx.FlowSystem(timesteps)
# Define and register custom carriers
flow_system.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'),
)
flow_system.add_elements(
# === Buses ===
fx.Bus('Gas', carrier='gas'),
fx.Bus('Steam', carrier='steam'),
# === Effect ===
fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),
# === Gas Supply ===
fx.Source(
'GasGrid',
outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)],
),
# === Main Industrial Boiler (with operational constraints) ===
fx.linear_converters.Boiler(
'MainBoiler',
thermal_efficiency=0.94, # High efficiency
# StatusParameters define on/off behavior
status_parameters=fx.StatusParameters(
effects_per_startup={'costs': 50}, # 50€ startup cost
min_uptime=4, # Must run at least 4 hours once started
min_downtime=2, # Must stay off at least 2 hours
),
thermal_flow=fx.Flow(
'Steam',
bus='Steam',
size=500,
relative_minimum=0.3, # Minimum load: 30% = 150 kW
),
fuel_flow=fx.Flow('Gas', bus='Gas', size=600), # Size required for status_parameters
),
# === Backup Boiler (flexible, but less efficient) ===
fx.linear_converters.Boiler(
'BackupBoiler',
thermal_efficiency=0.85, # Lower efficiency
# No status parameters = can turn on/off freely
thermal_flow=fx.Flow('Steam', bus='Steam', size=150),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
# === Factory Steam Demand ===
fx.Sink(
'Factory',
inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)],
),
)
flow_system = fx.FlowSystem(timesteps) # Define and register custom carriers flow_system.add_carriers( fx.Carrier('gas', '#3498db', 'kW'), fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'), ) flow_system.add_elements( # === Buses === fx.Bus('Gas', carrier='gas'), fx.Bus('Steam', carrier='steam'), # === Effect === fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True), # === Gas Supply === fx.Source( 'GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)], ), # === Main Industrial Boiler (with operational constraints) === fx.linear_converters.Boiler( 'MainBoiler', thermal_efficiency=0.94, # High efficiency # StatusParameters define on/off behavior status_parameters=fx.StatusParameters( effects_per_startup={'costs': 50}, # 50€ startup cost min_uptime=4, # Must run at least 4 hours once started min_downtime=2, # Must stay off at least 2 hours ), thermal_flow=fx.Flow( 'Steam', bus='Steam', size=500, relative_minimum=0.3, # Minimum load: 30% = 150 kW ), fuel_flow=fx.Flow('Gas', bus='Gas', size=600), # Size required for status_parameters ), # === Backup Boiler (flexible, but less efficient) === fx.linear_converters.Boiler( 'BackupBoiler', thermal_efficiency=0.85, # Lower efficiency # No status parameters = can turn on/off freely thermal_flow=fx.Flow('Steam', bus='Steam', size=150), fuel_flow=fx.Flow('Gas', bus='Gas'), ), # === Factory Steam Demand === fx.Sink( 'Factory', inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)], ), )
Run Optimization¶
In [5]:
Copied!
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms
MIP linopy-problem-btcfse2n has 1814 rows; 1311 cols; 5121 nonzeros; 432 integer variables (432 binary)
Coefficient ranges:
Matrix [1e-05, 6e+02]
Cost [1e+00, 1e+00]
Bound [1e+00, 1e+03]
RHS [1e-05, 7e+01]
WARNING: Problem has some excessively small row bounds
Presolving model
1212 rows, 645 cols, 2987 nonzeros 0s
0 rows, 0 cols, 0 nonzeros 0s
Presolve reductions: rows 0(-1814); columns 0(-1311); nonzeros 0(-5121) - Reduced to empty
Presolve: Optimal
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% 1441.320174 1441.320174 0.00% 0 0 0 0 0.0s
Solving report
Model linopy-problem-btcfse2n
Status Optimal
Primal bound 1441.3201735
Dual bound 1441.3201735
Gap 0% (tolerance: 1%)
P-D integral 0
Solution status feasible
1441.3201735 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.00
Max sub-MIP depth 0
Nodes 0
Repair LPs 0
LP iterations 0
In [6]:
Copied!
flow_system.statistics.plot.balance('Steam')
flow_system.statistics.plot.balance('Steam')
Out[6]:
Main Boiler Operation¶
Notice how the main boiler:
- Runs continuously during production (respecting min uptime)
- Stays above minimum load (30%)
- Shuts down during low-demand periods
In [7]:
Copied!
flow_system.statistics.plot.heatmap('MainBoiler(Steam)')
flow_system.statistics.plot.heatmap('MainBoiler(Steam)')
Out[7]:
On/Off Status¶
Track the boiler's operational status:
In [8]:
Copied!
# Merge solution DataArrays directly - xarray aligns coordinates automatically
status_ds = xr.Dataset(
{
'Status': flow_system.solution['MainBoiler|status'],
'Steam Production [kW]': flow_system.solution['MainBoiler(Steam)|flow_rate'],
}
)
df = status_ds.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value')
fig = px.line(df, x='time', y='value', facet_col='variable', height=300, title='Main Boiler Operation')
fig.update_yaxes(matches=None, showticklabels=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig
# Merge solution DataArrays directly - xarray aligns coordinates automatically status_ds = xr.Dataset( { 'Status': flow_system.solution['MainBoiler|status'], 'Steam Production [kW]': flow_system.solution['MainBoiler(Steam)|flow_rate'], } ) df = status_ds.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value') fig = px.line(df, x='time', y='value', facet_col='variable', height=300, title='Main Boiler Operation') fig.update_yaxes(matches=None, showticklabels=True) fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1])) fig
Startup Count and Costs¶
In [9]:
Copied!
total_startups = int(flow_system.solution['MainBoiler|startup'].sum().item())
total_costs = flow_system.solution['costs'].item()
startup_costs = total_startups * 50
gas_costs = total_costs - startup_costs
print('=== Cost Breakdown ===')
print(f'Number of startups: {total_startups}')
print(f'Startup costs: {startup_costs:.0f} €')
print(f'Gas costs: {gas_costs:.2f} €')
print(f'Total costs: {total_costs:.2f} €')
total_startups = int(flow_system.solution['MainBoiler|startup'].sum().item()) total_costs = flow_system.solution['costs'].item() startup_costs = total_startups * 50 gas_costs = total_costs - startup_costs print('=== Cost Breakdown ===') print(f'Number of startups: {total_startups}') print(f'Startup costs: {startup_costs:.0f} €') print(f'Gas costs: {gas_costs:.2f} €') print(f'Total costs: {total_costs:.2f} €')
=== Cost Breakdown === Number of startups: 3 Startup costs: 150 € Gas costs: 1291.32 € Total costs: 1441.32 €
Duration Curves¶
See how often each boiler operates at different load levels:
In [10]:
Copied!
flow_system.statistics.plot.duration_curve('MainBoiler(Steam)')
flow_system.statistics.plot.duration_curve('MainBoiler(Steam)')
Out[10]:
In [11]:
Copied!
flow_system.statistics.plot.duration_curve('BackupBoiler(Steam)')
flow_system.statistics.plot.duration_curve('BackupBoiler(Steam)')
Out[11]:
Compare: Without Operational Constraints¶
What if the main boiler had no startup costs or minimum uptime?
In [12]:
Copied!
# Build unconstrained system
fs_unconstrained = fx.FlowSystem(timesteps)
fs_unconstrained.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'),
)
fs_unconstrained.add_elements(
fx.Bus('Gas', carrier='gas'),
fx.Bus('Steam', carrier='steam'),
fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),
fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]),
# Main boiler WITHOUT status parameters
fx.linear_converters.Boiler(
'MainBoiler',
thermal_efficiency=0.94,
thermal_flow=fx.Flow('Steam', bus='Steam', size=500),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
fx.linear_converters.Boiler(
'BackupBoiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow('Steam', bus='Steam', size=150),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
fx.Sink('Factory', inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)]),
)
fs_unconstrained.optimize(fx.solvers.HighsSolver())
unconstrained_costs = fs_unconstrained.solution['costs'].item()
print('=== Comparison ===')
print(f'With constraints: {total_costs:.2f} €')
print(f'Without constraints: {unconstrained_costs:.2f} €')
print(
f'Constraint cost: {total_costs - unconstrained_costs:.2f} € ({(total_costs - unconstrained_costs) / unconstrained_costs * 100:.1f}%)'
)
# Build unconstrained system fs_unconstrained = fx.FlowSystem(timesteps) fs_unconstrained.add_carriers( fx.Carrier('gas', '#3498db', 'kW'), fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'), ) fs_unconstrained.add_elements( fx.Bus('Gas', carrier='gas'), fx.Bus('Steam', carrier='steam'), fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True), fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]), # Main boiler WITHOUT status parameters fx.linear_converters.Boiler( 'MainBoiler', thermal_efficiency=0.94, thermal_flow=fx.Flow('Steam', bus='Steam', size=500), fuel_flow=fx.Flow('Gas', bus='Gas'), ), fx.linear_converters.Boiler( 'BackupBoiler', thermal_efficiency=0.85, thermal_flow=fx.Flow('Steam', bus='Steam', size=150), fuel_flow=fx.Flow('Gas', bus='Gas'), ), fx.Sink('Factory', inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)]), ) fs_unconstrained.optimize(fx.solvers.HighsSolver()) unconstrained_costs = fs_unconstrained.solution['costs'].item() print('=== Comparison ===') print(f'With constraints: {total_costs:.2f} €') print(f'Without constraints: {unconstrained_costs:.2f} €') print( f'Constraint cost: {total_costs - unconstrained_costs:.2f} € ({(total_costs - unconstrained_costs) / unconstrained_costs * 100:.1f}%)' )
Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms LP linopy-problem-v4ek6gzn has 516 rows; 660 cols; 1672 nonzeros Coefficient ranges: Matrix [6e-02, 1e+00] Cost [1e+00, 1e+00] Bound [5e+01, 1e+03] RHS [0e+00, 0e+00] Presolving model 72 rows, 144 cols, 144 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-516); columns 0(-660); nonzeros 0(-1672) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-v4ek6gzn Model status : Optimal Objective value : 1.2782270712e+03 P-D objective error : 8.8906257074e-17 HiGHS run time : 0.00 === Comparison === With constraints: 1441.32 € Without constraints: 1278.23 € Constraint cost: 163.09 € (12.8%)
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
In [13]:
Copied!
flow_system.statistics.plot.sankey.flows()
flow_system.statistics.plot.sankey.flows()
Out[13]:
Key Concepts¶
StatusParameters Options¶
fx.StatusParameters(
# Startup/shutdown costs
effects_per_startup={'costs': 50}, # Cost per startup event
effects_per_shutdown={'costs': 10}, # Cost per shutdown event
# Time constraints
min_uptime=4, # Minimum hours running once started
min_downtime=2, # Minimum hours off once stopped
# Startup limits
max_startups=10, # Maximum startups per period
)
Minimum Load¶
Set via Flow.relative_minimum:
fx.Flow('Steam', bus='Steam', size=500, relative_minimum=0.3) # Min 30% load
When Status is Active¶
- When
StatusParametersis set, a binary on/off variable is created - Flow is zero when status=0, within bounds when status=1
- Without
StatusParameters, flow can vary continuously from 0 to max
Summary¶
You learned how to:
- Add startup costs with
effects_per_startup - Set minimum run times with
min_uptimeandmin_downtime - Define minimum load with
relative_minimum - Access status variables from the solution
- Use duration curves to analyze operation patterns
Next Steps¶
- 05-multi-carrier-system: Model CHP with electricity and heat
- 06a-time-varying-parameters: Variable efficiency based on external conditions